feat(delegate): unsubscribe, subscription introspection, and pin every wire tag - #98
Conversation
fecdf1c to
eea3c0a
Compare
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #98
Summary
- PR Title: feat(delegate): subscription introspection, and pin every wire tag
- Type: feat (wire-format guards + a new V2 host function)
- CI Status: green on the reviewed HEAD; re-running on the fix commit
- Linked Issues: freenet-core#5467, #4669, #5487; freenet-stdlib #99, #100
- Review tier: Full — touches wire format / protocol serialization on the delegate enums, an always-Full surface
- Reviewers run:
code-first,testing,skeptical,big-picture(four parallel Claude lenses, each blind to the others), pluscodex reviewas the external non-Claude pass. All five read the checked-out code in an isolated worktree. - Disclosure: I am the author. Every finding below was raised by a reviewer that did not write the code, and I verified each against source before acting on it.
Code-First Analysis
Independent Understanding: four separable things — a new DelegateCtx::list_subscriptions plus two WASM imports and a shared codec; corrections to three doc comments; two test modules pinning bincode variant tags and demonstrating struct-field compat; two unrelated fixes in memory/buf.rs.
Stated Intent: matches, with one exception found by the big-picture lens (below).
Alignment: good on the wire-guard half. The list_subscriptions half was justified by a false premise — now corrected.
Testing Assessment
Coverage Level: adequate after fixes; two guards were vacuous before them.
| Test Type | Status | Notes |
|---|---|---|
| Unit | ✅ | 112 pass (was 111; off-WASM contract test added during review) |
| Integration | N/A | library crate; the host half lands in freenet-core |
| Simulation | N/A | no routing/topology surface |
| E2E | the two new WASM imports cannot be exercised until the core half exists — stated in the PR rather than papered over |
Regression Test: present — the shipped ContractState::size_bytes append is pinned as a concrete instance of the rule.
Skeptical Findings
Risk Level: medium before fixes, low after.
The wire-safety claim itself held under adversarial check: both delegate enums are byte-identical to origin/main, no variant or payload field added, removed or reordered, and no generated-file churn rode in. The problems were in the guards and the prose, not the wire.
| Concern | Severity | Location | Status |
|---|---|---|---|
Terminality pin vacuous — all-default fixture encodes as 27 zero bytes, so ends_with asserted only "ends in zeros", which survives appending any zero-encoding field |
High | client_events.rs:4126 |
Fixed — non-zero fixture, an assertion that the fixture is not all-zero, and a length equality |
Unknown-tag probe asserted only is_err(), so a variant whose payload rejects zeros fails for the wrong reason and drift goes undetected |
High | delegate_interface.rs:1591 |
Fixed — asserts the error names an invalid variant index, plus a control that the last known tag still decodes |
list_subscriptions could silently under-report: unchecked i64 as usize (32-bit on wasm32), written > len ignored by truncate, and a set that grew between the two calls returning a short list that looks complete |
Medium | delegate_host.rs:743 |
Fixed — length validated against a new MAX_SUBSCRIPTION_LIST_BYTES, written > len rejected, exactly-full buffer re-checked, and the import contract now specifies ERR_BUFFER_TOO_SMALL |
#[allow(non_snake_case)] claimed to suppress nothing |
Low | buf.rs:369 |
Rejected — false positive. rustc rejects consecutive interior underscores; I have the clippy output showing it fires |
Big Picture Assessment
Goal Alignment: yes for the wire guards. The list_subscriptions justification had drifted and is corrected.
The most valuable finding in the review, and the one I would not have caught: the doc claimed "the node replays subscriptions across a restart without running the delegate at all" — the entire motivation for the API. It is false. DELEGATE_SUBSCRIPTIONS is an in-memory LazyLock<DashMap> with no persistence, so a restart loses delegate subscriptions rather than replaying them. Verified in freenet-core. The API is still worth adding — it is the read side of that capability — but it does not deliver restart-replay until #4669 part 3's durable store lands, and it now says so.
Two further false claims, both verified and corrected:
- The
#[non_exhaustive]rationale citedSubscribeContractRequestas a variant that compiles with no handler. It is handled (contract.rs:916-940); its defect is that it registers no demand. A different bug with a different fix. The argument stands on its own and now states two honest limits: the compile error forces an arm to exist, not a working handler — this crate's own encoder has arms that log and drop. - The version floor named a stdlib version. Host functions register by name and reference no stdlib type, so that guarantees nothing; corrected to name the core requirement, and to say no released node provides these imports yet.
Anti-Patterns Detected: none of the CI-chasing kind. No test removed, skipped, or loosened; inbound_delegate_msg_wire_format_is_stable is present and byte-identical to main.
Scope Assessment: some creep, acknowledged. Five arguably-separable things. The buf.rs fixes and the CONTRIBUTING line were flagged in the PR body deliberately; the big-picture lens correctly notes that flagging is transparency, not focus. Splitting list_subscriptions out would be defensible.
External Model (codex)
Converged independently on the probe defect (P2, same location), which is the strongest signal in the review — three Claude lenses and the external model found the same hole from different directions.
Its suggested remedy was wrong, instructively. It recommended asserting ErrorKind::InvalidTagEncoding. bincode never produces that for an enum tag: it has exactly one construction site, in deserialize_option (de/mod.rs:340). The trap is that its description string reads "tag for enum is not valid" — codex was misled by the description exactly as the original code was misled by the name. A test written to codex's suggestion would never have passed. The fix asserts the error names an invalid variant index instead, which the passing test empirically confirms.
Documentation
- Code docs: complete, and now accurate — four claims corrected against source.
- CHANGELOG: corrected. The "additive for every existing delegate" claim is now evidence rather than prose: River's shipped
chat_delegate.wasmbuilds against stdlib 0.8.5, which declares the fivefreenet_delegate_contractsexterns, andwasm-objdump -xshows it imports none of them. (A first attempt to demonstrate this with a synthetic delegate was itself vacuous — the harness exported no entry point, so nothing could import, and an empty module reports zero the same way.) - Noted, not fixed: the repo-root
examples/delegate.rsno longer compiles againstDelegateInterface— it uses a 5-parameter signature with aSecretsStoreargument the trait has not had for some time. It is not wired into any Cargo target, so nothing catches it. Worth its own issue.
Recommendations
Must Fix (Blocking)
All resolved in be638f3:
- Terminality pin vacuous.
- Unknown-tag probe asserted only
is_err(). - Restart-replay premise false.
SubscribeContractRequestcited as unhandled.- Version floor named a stdlib version.
Should Fix (Important)
- FFI hardening for the truncation and cast paths — done.
- Best-effort/lossy notification delivery now documented — done.
- "Old delegate → new host always fine" qualified: true for appended variants, not for fields appended to their payload structs — done.
Consider (Suggestions)
- Split
list_subscriptionsinto its own PR. Not done — it is the piece the host half is written against, and the ABI is whatchokepointneeds. - Pin the request/response enums (
HostResponse,QueryResponse,ContractRequest) the same way. Genuinely worth doing and out of scope here; the response direction is entirely unpinned today. - A
HIGHEST_TAG_EVER_USEDwatermark, since removing the last variant is invisible to the whole scheme. - File the stale
examples/delegate.rs.
10–12 are follow-ups, not merge blockers.
Verdict
State: Needs Changes — Re-review Required After Fix (fixes are pushed; a re-review pass on the new HEAD is required before merge, per the per-code-content rule — five blocking findings were addressed, which is well past the threshold)
HEAD SHA reviewed: 42ecc979ac9bce4e1ba80e4b27f94baaf55e570d
Fixes pushed as: be638f3
I am not merging this. The review found real defects — including two guards that would have passed while measuring nothing, in a PR whose entire purpose is to stop exactly that — and the code has changed materially since the reviewed SHA.
[AI-assisted - Claude]
Adds `DelegateCtx::list_subscriptions` so a delegate can ask the node what it is subscribed to. Its subscription set lives in the node, not in the delegate: the WASM is instantiated per invocation and dropped afterwards, and the node replays subscriptions across a restart without running the delegate at all, so a delegate had no way to learn its own state (freenet-core#5467). It returns a `Result`, not a bare `Vec` — an empty list and a failed enumeration mean opposite things to a caller deciding whether to re-subscribe. Delivered as a V2 host function rather than a message variant. Host functions resolve by name at instantiation, so this is additive for every existing delegate and fails at load time with a named missing-import error on a node too old to provide it, instead of mid-protocol on a decode. Pins the bincode tag of EVERY variant of both delegate message enums. The previous pin covered `InboundDelegateMsg`'s variant 0 alone, so any reorder that left `ApplicationMessage` first went undetected — including swapping `UserResponse` and `GetContractResponse`, which reassigns two tags and makes deployed delegate WASM read each as the other, silently. That exact swap was written during the work that produced this pin. The guard fails closed both ways: the tag map is an exhaustive match, so a new variant is a compile error until pinned, and a probe asserts the next tag along does not decode. Also asserts the compatibility rules rather than only stating them. Appending an enum variant and appending a struct field break in opposite directions, and a struct field is the more dangerous: bincode is positional with no field tags, so an old payload fails outright on a new receiver, and a new field is skipped cleanly only when the struct is terminal in its message. `#[serde(default)]` does not make a bincode field optional; it protects the serde_json path only. Corrects two false doc comments: `InboundDelegateMsg` claimed `OutboundDelegateMsg` was `#[non_exhaustive]` (it never has been, and it is deliberately staying un-marked so the host cannot gain a variant without a handler), and `subscribe_contract` claimed notification delivery was a follow-up while saying nothing about the fact that a delegate subscription registers no demand in the network (freenet-core#4669). No wire change: no variant is added, removed or reordered. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
…test enums The old-tag-space and envelope types in the compat tests exist to occupy wire space and to be deserialized into, never to be constructed, which trips dead_code under CI's -D warnings. Also drops a doc comment's reference to what a previous version of that same comment said, which is of no use to a reader of the published API. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
…he rule ContractState::size_bytes was appended in #52 (2026-02-18, crate 0.1.36) and ships in every tag from rust-v0.8.0. ContractState is a HashMap VALUE inside NodeDiagnosticsResponse with two more fields after the map, so the appended u64 is not trailing — it shifts what follows, and an older reader does not get a response missing one field, it gets no response at all. Isolates the single variable by using today's String map key, so it measures the appended field rather than the later key change in #70. Scope, stated so the finding is not read as larger than it is: the only external consumer of this query is fdev diagnostics, which ships from core's own tree and is version-matched in practice; River touches NodeDiagnostics only in tests and pins stdlib 0.8.5. The exposure is an fdev built before 2026-02-18 pointed at a newer node. The value here is the rule with a real instance attached, not the instance. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
Its fields are decoded into and never read, which is the test: the decode either fails or produces something wrong. CI denies warnings. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
HostResponse defaults its type parameter to WrappedState, which is what goes over the wire. The terminality pin was instantiating it at Vec<u8>, so it was pinning the layout of a type nobody sends. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
…operty net-wiring's freenet-core#4669 work changes whether a delegate subscribe registers demand, so stating 'registers no demand' as a fact about this API would be wrong the moment their PR merges — the same doc rot in the other direction. Reframed as node behaviour with a tracking reference: pre-#4669 nodes register no demand at all; post-#4669 nodes register it when hosting the contract, and still do not when they can resolve but are not hosting, since a pin on an unheld contract could be neither renewed nor reclaimed. The delegate cannot detect which node it has, and the call reports success in every case. Also documents list_subscriptions' real cost: the node keys delegate subscriptions contract -> delegates, so this is a scan across every contract with any delegate subscription, not O(this delegate's). Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
After freenet-core#4669 a delegate subscription is two records — the notification hook and the demand registration — and they can separate. An eviction that sheds a still-in-use contract clears the demand and leaves the hook, so a list read from the hook alone reports a contract the delegate is no longer pinning. That 'looks subscribed, is not pinned' state is what #5467 exists to make visible, and reproducing it inside the introspection API meant to reveal it would be the same defect one layer up. A delegate replaying this list after a restart would also re-subscribe to things it holds no demand for and believe it had recovered. Promises the narrower meaning deliberately, so tightening the host's answer to the cross-checked set later is a bug fix rather than a breaking change. Reported by net-wiring from the core side, where the divergence is visible and from stdlib it is not. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
Filed by net-wiring, covering both subscription desyncs and why the two obvious fixes are wrong. Points the reader at the mechanism instead of my summary of it, and records that introspection built against the two-record shape wants rewriting when #4669 part 3's single store lands. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
… getter StreamingBuffer::from_ptr is a pub unsafe fn whose entire doc comment, including its '# Safety' section, was attached to total_remaining — a safe getter two lines above that takes no pointer. So the getter was documented as 'Create a streaming reader from a buffer pointer' with a safety contract about a ptr it does not have, and the unsafe constructor, whose callers must uphold that invariant, had no documentation at all. A method had been inserted into the middle of another method's doc block. Splitting them back apart also clears clippy::missing_safety_doc. The other error in this file is a false positive: the non-WASM stub must keep the mangled __frnt__fill_buffer name to match the WASM import it stands in for, so it gets a narrow allow with the reason. Neither is reachable by CI's lint gate, because ci.yml:99 passes no --features: the clippy matrix covers both wasm32 and x86_64, but 'contract' is off in both legs, and both of these live behind #[cfg(feature = "contract")]. Filed as issue #100; not fixed here, because CI is a shared Full-tier surface. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
…fers files A build regenerates rust/src/generated/ with whatever local flatc is present, which is not the one that produced the checked-in files, so any build leaves thousands of lines of unrelated churn in the working tree. Staging explicit paths is what keeps it out; a single 'git add -A' puts a toolchain downgrade into a PR where nobody is looking for one. Found while preparing this branch: the diff against main showed ~4000 lines of generated churn that the committed diff did not contain. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
Multi-lens review found the terminality pin vacuous and three doc claims wrong. Each was verified against source before being changed. Vacuous test, found independently by three reviewers: the terminality pin built NodeDiagnosticsResponse from all defaults, which encodes as 27 ZERO bytes, so ends_with(inner) asserted only that the message ends in zeros. That stays true after appending any field that encodes as zeros, which is exactly the mutation it claims to catch. Now uses a distinctive non-zero fixture, asserts the fixture is not all-zero, and adds a length equality so an appended sibling cannot hide even if its bytes coincide. The unknown-tag probe asserted only is_err(), so a future variant whose payload rejects zeros (a DateTime, a NonZero, a validating deserialize_with) would fail for the wrong reason and leave the count constants drifting undetected. It now asserts the error names an invalid variant index, plus a control that the last known tag still decodes from the same payload, so it cannot go vacuous either way. Wrong claim 1: bincode does NOT reject an unknown enum tag with InvalidTagEncoding. That is produced only for a bad Option discriminant (de/mod.rs:340); deserialize_enum hands the index to serde's derived visitor, producing ErrorKind::Custom naming an invalid variant index. Stated in two doc comments; corrected, and now pinned by the probe above. Wrong claim 2: the node replays subscriptions across a restart is false, and it was the motivating premise for list_subscriptions. DELEGATE_SUBSCRIPTIONS is an in-memory LazyLock DashMap with no persistence, so a restart LOSES them. The API is still right to add, but it is the read side of a capability that needs #4669 part 3's durable store; scoped accordingly. Wrong claim 3: the non_exhaustive rationale cited SubscribeContractRequest as a variant compiled with no handler. It IS handled (contract.rs:916-940); its defect is that it registers no demand. Different bug. The argument stands on its own and now states two honest limits: the compile error forces an arm to exist, not a working handler, and this crate's own encoder has arms that log and drop. Wrong claim 4: the version floor named a stdlib version. Host functions are registered by name and reference no stdlib type, so the stdlib a node was built against guarantees nothing. Says so, and that no released node provides these imports yet. Also hardens the FFI, which reviewers found could silently under-report. Validate that the host length is a multiple of 32 and within a new MAX_SUBSCRIPTION_LIST_BYTES before allocating: usize is 32-bit on wasm32, so an unchecked i64 cast truncates to 0 and surfaces as an empty list, the exact conflation the Result return type exists to prevent. Reject written > len, which truncate would otherwise ignore, leaving zero-filled tail bytes to decode as valid-looking all-zero ids. Re-check on an exactly-full buffer so a set that GREW between the two calls returns ERR_BUFFER_TOO_SMALL rather than a short list that looks complete. The import contract now specifies that requirement, since the host half is written against it. Adds the off-WASM test that list_subscriptions returns Err rather than an empty list; documents that notification delivery is best-effort and lossy; qualifies the old-delegate-to-new-host claim, which holds for appended variants but not for fields appended to their payload structs; and renames a test to what it actually pins. The additive claim is now evidence: River's shipped chat_delegate.wasm builds against stdlib 0.8.5, which declares the five freenet_delegate_contracts externs, and imports none of them. A first attempt to show this with a synthetic delegate was itself vacuous, since that harness exported no entry point, so nothing could import and an empty module reports zero the same way. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
freenet-core#2830 specified subscribe and unsubscribe together; only subscribe was built, and core has carried the TODO(#2830) since. Until now the only way a delegate's subscription was released was the implicit cleanup when the delegate itself was unregistered, so a delegate that had finished with a contract kept holding interest for as long as it existed. Appends UnsubscribeContractRequest to OutboundDelegateMsg and UnsubscribeContractResponse to InboundDelegateMsg, both at tag 8. Every existing tag is unchanged, so deployed delegate WASM is unaffected. Tag 8 is Ian's call, coordinated with freenet-stdlib#82, which appends ScheduleWakeup/WakeupFired and now takes tag 9; recorded as a comment on that PR so the decision does not live only in a working session. Unsubscribing a contract the delegate is not subscribed to reports Ok(()). Not a convenience: the host's teardown already treats an absent client id as a no-op, so an error return would have it inventing a failure it did not have. That reasoning is net-wiring's, from the side that implements it, and it survives someone later deciding convenience was not a good enough justification. The pin was made to fail before it was made to pass. Adding the two variants with nothing else changed produced five compile errors, all E0004 non-exhaustive-pattern: three in production code, including the FlatBuffers encoder in client_api/client_events.rs that the OutboundDelegateMsg doc cites as the reason the enum is deliberately NOT non_exhaustive, and two in the tag pin itself (pinned_inbound_tag, pinned_outbound_tag). So the guard is demonstrated rather than asserted: a variant cannot be appended without both the host dispatch site and the wire pin refusing to compile. Adds a round-trip test for the pair that also asserts a hand-built pre-0.9.0 ContractNotification still decodes unchanged, so the append is shown not to disturb anything older. The host half is freenet-core's and is owned by net-wiring, who has the exact field layout. This does not ship until they confirm they are landing it — shipping a variant no host handles would be the same defect this workstream exists to remove. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
Re-review of the unsubscribe commit found one real design mistake, the oracle problem in my own new test, and a false claim I had just reintroduced. The exactly-full re-check was wrong twice over. It re-called the length function whenever the read came back exactly filling the buffer, meaning to catch a set that had grown. But an exactly-full buffer is the NORMAL result, not an edge case: len is derived from the same set the read serialises. So it doubled a scan the docs describe as O(all contracts with any delegate subscription) on every non-empty call, and it could fail a correct read by reporting ERR_BUFFER_TOO_SMALL when a subscription happened to arrive in between. It was not even sound: grow-then-shrink passes it. Removed. The import contract already requires the host to return ERR_BUFFER_TOO_SMALL rather than truncate, so a short write means the set shrank, and completeness rests on that contract, which is why the contract is stated on the import rather than implied. The decisions now live in validate_list_len and resolve_written, which are pure and compiled on every target. That matters because CI runs cargo test on the host only; the wasm32 matrix entries build and lint but execute nothing, so everything previously inside cfg(target_family = "wasm") was type-checked and never run. Ten table-driven tests now cover the branches, including the wasm32 truncation case (1 << 32 as usize is 0 there, which would have surfaced as an empty list). My round-trip test for the new unsubscribe pair proved only that the code agrees with itself. Both structs' docs say the field ORDER is the wire format, and a round-trip through this crate's own encoder cannot establish that — swapping contract_id and result would round-trip just as happily. Both layouts are now frozen as hand-written bytes, the inbound half asserts the VALUES rather than just the variant, and the Err(String) path is exercised since it has a different bincode shape from Ok. Reintroduced false claim, the same defect class as this PR's headline fix: the doc said "several of them are #[non_exhaustive]" of the payload structs. Exactly one is, ApplicationMessage. Corrected and named. Also: the #82 note asserted that PR takes tag 9, which it does not yet — it still declares 8, so the text now says it must move and that the pin will catch whichever lands second. The unknown-tag probe gained an outbound control to match the inbound one. The terminality fixture's non-zero guard asserted some byte was non-zero when what ends_with relies on is a non-zero TAIL. Fixes a pre-existing bug found in review: get_context and get_mut_context returned None for UserResponse, which carries a context, because a `_ => None` wildcard swallowed the missing arm and nothing in the crate called either accessor. Arm added, both accessors are now exhaustive with no wildcard, and a table-driven test drives them off every_inbound/every_outbound so the next omission is a compile error rather than a silent None. Filed #101 for four sibling sites with the same unvalidated-length shape. One of these fixes was itself wrong first: replacing expect_err with unwrap_or_else to make a panic message interpolate inverted the test, since unwrap_or_else unwraps Ok and runs the closure on Err. The compiler caught it. Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
0.9.0 is already published (crates.io, 2026-09-04) and `main` sits at it, so this change cannot ship under the version it was written against. Appending `UnsubscribeContractRequest` to `OutboundDelegateMsg`, which is deliberately not `#[non_exhaustive]`, is a source-breaking change for every downstream exhaustive match. On a 0.x line that is a minor bump, so 0.10.0 rather than 0.9.1. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
d4ad47e to
46bdd33
Compare
…racking issue Re-review findings on the rebased head. - Five comments said the unsubscribe pair was "appended in 0.9.0". This PR is what moves the crate off 0.9.0, which is already published, so the pair ships in 0.10.0. Anyone asking "which stdlib do I need for unsubscribe" got the wrong answer from five separate places, in a PR whose purpose is precision about wire versioning. The 0.9.0 reference at the RegisterDelegateWithPredecessors removal is genuine history and is deliberately left alone. - The doc claimed the FlatBuffers encoder has "five" log-and-drop arms. It has six -- this PR's own UnsubscribeContractRequest arm is one of them, so the count was stale the moment it was written. - freenet-core#2830 was cited as the tracking issue for the host-side unsubscribe handler. It is CLOSED as COMPLETED: it specified subscribe and unsubscribe, only subscribe was built, and it was closed anyway. Filed freenet-core#5600 for the outstanding half. Also investigated and dismissed a reported intermittent test failure under `--features contract`. It did not reproduce: 140 consecutive runs clean across both feature sets, including under CPU load, in a worktree with no other process in it. The original observation came from a worktree that two reviewers were sharing while one of them was appending and recompiling probe modules, which is sufficient to explain it. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
sanity
left a comment
There was a problem hiding this comment.
Re-review after rebase — Full tier
Required because the content changed: rebased onto main (029a31a), re-versioned, and the prior review's verdict was explicitly "Needs Changes — Re-review Required" with five blocking fixes unreviewed.
Reviewed HEAD: 46bdd33 · Fixes pushed as: 4c33b95
Lenses: code-first, skeptical, testing, big-picture (four parallel Claude reviewers, each blind to the others) plus codex review as the external non-Claude pass. Five total.
Tier: Full — wire format / protocol serialization, an always-Full surface.
Disclosure: I rebased and re-versioned this PR but did not write its substance. Every finding below came from a reviewer that did not do that work, and I verified each against source before acting.
The rebase
Only CHANGELOG.md conflicted; every code file applied clean across all 13 commits. The resolution kept both sides' additions, with one judgement call worth naming:
A semantic conflict git could not see. This branch carried a ### TypeScript SDK — Breaking (next release must be 0.4.0, not a patch) section. main has since done that release (55dcc2d), so it now carries ### TypeScript SDK 0.4.0 — Breaking with the same body. Git auto-merged the shared body and left both headers, producing a changelog that announced a release as forthcoming directly above the entry recording it as done. Dropped the stale forward-looking header, kept main's released one.
Confirmed the rebase preserved intent: diff vs main is +1818/-23 across the same 8 files, and no generated FlatBuffers file was touched — worth checking explicitly because main gained a bindings-drift CI job (#110, #125) after this branch was cut. That job passes.
Findings
| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | Five comments said the unsubscribe pair was "appended in 0.9.0" | Medium | Fixed |
| 2 | Doc claimed the FlatBuffers encoder has "five" log-and-drop arms; it has six | Low | Fixed |
| 3 | freenet-core#2830, cited as the tracking issue for the host half, is CLOSED as COMPLETED | Low | Fixed — filed freenet-core#5600 |
| 4 | Intermittent test failure / suspected memory corruption under --features contract |
High (claimed) | Not reproducible — dismissed, see below |
(1) is the one that would have cost someone real time. 0.9.0 is already published and this PR is what moves off it, so the pair ships in 0.10.0. Five separate comments gave the wrong answer to "which stdlib do I need for unsubscribe", in a PR whose entire purpose is precision about wire versioning. The 0.9.0 reference at the RegisterDelegateWithPredecessors removal is genuine history and was deliberately left alone.
(2) was stale the moment it was written — this PR's own UnsubscribeContractRequest arm is the sixth.
(3) #2830 specified subscribe and unsubscribe, only subscribe was built, and it was closed as completed anyway. The TODO(#2830) at contract.rs:933 is accurate about the code but points at an issue that reads as resolved. Filed freenet-core#5600 for the outstanding half rather than reopening, so #2830 keeps its accurate history.
On finding (4), because dismissing a High needs evidence
The code-first lens reported two failures in ~37 runs — corrupted bytes in a wire test, and a wrong result from resolve_written(320, 352), a pure function with no I/O — and reasonably read the combination as UB in the raw-pointer paths.
It does not reproduce. 140 consecutive clean runs, across both --features contract and --features contract,net,testing,trace, including under saturating CPU load, in a worktree with no other process in it.
The explanation is mine to own: I gave all four reviewers one shared worktree. The testing lens independently reported seeing a zzz_probe module it did not write being appended to delegate_interface.rs by another process, with a live rustc running — i.e. one reviewer was mutating and recompiling the sources while another executed the test binary. That is sufficient to produce exactly what was observed, and it is the failure mode never-trust-cwd-with-parallel-agents exists to prevent. The finding was a real observation of a real artifact; the artifact was my process error, not this code.
Recorded rather than quietly dropped, because "could not reproduce" is the phrase under which genuine intermittent faults get buried.
What the lenses verified rather than accepted
The prior review found four doc comments asserting false facts, so claims were checked against source:
DELEGATE_SUBSCRIPTIONSis in-memory only — true (native_api.rs:41-43), no persistence path anywhere it is touched. A restart loses delegate subscriptions.- freenet-core matches
OutboundDelegateMsgexhaustively with no wildcard — true at four sites (contract.rs:677,contract.rs:2187,execution.rs:365,execution.rs:62). - No released node registers the
list_subscriptionsimports — true; core registers five functions infreenet_delegate_contracts(wasmtime_engine.rs:2048-2113), not these two.
No fifth false claim was found.
Guards were made to fail
The testing lens applied real mutations and confirmed each guard fires:
| Mutation | Result |
|---|---|
Swapped UserResponse/GetContractResponse declaration order |
tag pin fails (left: 2, right: 1) |
Swapped field order in UnsubscribeContractResponse |
hand-built byte comparison fails |
Added a trailing field after NodeDiagnosticsResponse |
terminality pin fails |
Removed decode_contract_id_list's remainder().is_empty() check |
truncation test fails |
Removed resolve_written's written > len guard |
fails (Ok(352) for expected Err(-8)) |
It also confirmed the prior review's "twelve tests never executed" defect is genuinely fixed: zero new tests sit behind #[cfg(target_family = "wasm")], and all 32 execute under CI's exact invocation.
External model
codex review --base origin/main: "No actionable correctness defects were found. The new wire variants, context accessors, and subscription-list validation are internally consistent." It independently swept the version references and reached the same set I had corrected.
Non-blocking follow-ups
examples/delegate.rsis stale and wired into no Cargo target — itsprocess()has a 5-arg signature the trait has not had for some time. Pre-existing; worth an issue.- The "exactly-full buffer is fine" assumption in
resolve_writtenrests on a freenet-core contract that does not exist yet. Whoever implements the host half should treat "wrote exactlyout_len" as ambiguous and returnERR_BUFFER_TOO_SMALLon any race rather than truncating silently. Noted on freenet-core#5600.
Verdict
Approve-equivalent, subject to CI green on 4c33b95. All four findings are fixed or specifically dismissed with evidence. I am not merging it — that decision is the team lead's and Ian's, and freenet-stdlib#82 is stacked on this branch and should land in the same sequence.
[AI-assisted - Claude]
|
Merged with an admin bypass, recorded here so the audit trail explains itself. Why a bypass was needed, and why it is not a skipped gate. The actual quality gate was passed, and it is stricter than the branch policy: a four-lens Full-tier re-review plus an external The rebase was clean in a way worth recording, since the branch carried a warning that it "reverts four fixes": only One finding from this PR's own machinery, worth keeping: the variant-tag pin added here got its first real workout when #82 was rebased onto it — four Next: #82 retargets to [AI-assisted - Claude] |
`DelegateCtx::subscribe_contract`'s doc now describes at length that a subscribe may register no demand, and that a delegate cannot detect which it got. This adds the way to detect it. `true` means "the node accepted the registration", not "the contract is pinned". A registration accepted without durable demand is reported identically to one that pinned, so the delegate believes it holds durable interest and does not: the contract stays evictable and notifications later stop at a moment the delegate never observes. Adds `subscribe_contract_checked`, returning `SubscribeOutcome` (`Pinned` / `NotPinned` / `Unrecognized`). `is_pinned()` is true only for `Pinned`: an outcome this build cannot interpret is not evidence of a pin, and folding it into success would reintroduce the exact defect. This is deliberately NOT a wire-format change. It is a host function and a plain Rust type, so it takes no bincode variant tag and cannot shift one -- additive in both directions. A delegate that does not import it is unaffected; one that does fails to *instantiate* on too old a node with a named missing-import error, loudly and at load time. Same reasoning as `list_subscriptions`, which this complements rather than duplicates: that reads the subscription set, which contains accepted-but-unpinned registrations, so it answers "what did I register?" where this answers "what actually got pinned?". `subscribe_contract` is left behaviourally unchanged; altering its return would change the behaviour of already-deployed delegate WASM. Reapplied onto main after #98 merged rather than rebased: #98 was squash-merged, so replaying this branch would have re-applied #98's own commits against a main that already contains them. Reapplying also let this build on #98's much richer subscribe_contract doc, pointing at the new method instead of overwriting what that doc now explains. No version change -- main is already at 0.10.0 from #98. Host half: freenet/freenet-core#5565. No released node provides the import yet. Verified: 130 tests pass; the unknown-outcome guard was mutated (unknown folded into `Pinned`) and confirmed to FAIL. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
Two findings from the wire review, both free to fix now. `SubscribeOutcome` was missing from `prelude`'s explicit re-export list, so `use freenet_stdlib::prelude::*` brought in `subscribe_contract_checked` but not the type its result must be matched on. Matching the outcome is the entire point of the API, and it needed a second, differently shaped import to do it. The error type is now `i64`, matching both the host function's own return type and `list_subscriptions` from #98. The previous `i32` narrowed with `unwrap_or(i32::MIN)`, so a code outside i32 range became -2147483648 -- a value no host ever sent and that appears in no error table. Every available narrowing answer invents a code; not narrowing does not. The prelude guard was rewritten after the first version proved vacuous. It used `use crate::prelude::*` inside a test module that already has `use super::*` in scope, so the name resolved from the parent module and the test passed with the re-export deleted -- confirmed by deleting it. It now names `crate::prelude::SubscribeOutcome` in full, and deleting the re-export fails the build with E0433. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
…ounds Round-2 findings. M1 is behavioural; the rest are the same defect in prose, which is that this crate was describing obligations on an unwritten host in the present tense. M1: `schedule_wakeup` now clamps `after` up to MIN_WAKEUP_DELAY itself. The doc said the host "will honour" the floor, that the constant "states the contract so a delegate author can rely on it", and that "#3972 enforces it" -- three lines above a paragraph correctly saying no host does anything yet. Two registers in one comment, and the second was the honest one. One line in the wrapper makes the sentence true of the API the delegate actually calls, and it is how MAX_WAKEUP_TAG_BYTES was already handled; enforcing one documented bound locally while delegating the other to an unwritten host had no stated reason. The clamp is a pure `clamp_wakeup_delay` rather than an inline `.max()`, because the call site is inside `cfg(target_family = "wasm")` and CI never executes that branch -- the wasm32 jobs build and lint only. A clamp living only there would be type-checked and unverified, which is the "twelve tests that never ran" shape #98 found in this same crate. M3: the test comment said the tag cap "must be enforced before the host call, not by the host". That is backwards, and it was addressed to the person who has to write the host check. `__frnt__delegate__schedule_wakeup` is an ordinary WASM import, so a delegate can declare its own extern block and pass a 10 MB tag without ever touching DelegateCtx. A guest-side check on a guest-controlled import is fail-fast convenience and can never be a bound. Only the host can enforce it. Generalised in the comment, since it is true of every bound this crate documents. M2: "the host bounds how many wakeups a delegate may hold pending" was present tense in two places and false. It is load-bearing rather than incidental -- the reader is told a count cap exists in order to explain why a size cap is also needed -- so it is now an obligation on #3972 alongside durability. M4/L1: deleted a stale CHANGELOG run. It claimed `Duration` was on the wire with a byte layout pinned by a test; neither survived the M1 conversion, which moved the delay to `after_millis: i64` across the FFI and removed the outbound pin entirely. It also carried a duplicate durability section and a `### Why a delay` heading that escaped the entry it belonged to. Replaced with a Compatibility section describing what actually ships. L2: removed the superseded no-context rationale from the `get_context` comment. Two rationales coexisted and the weaker one was what a maintainer hits first when editing the accessor. Verified: 133 tests pass; both clippy invocations clean; fmt clean. The clamp guard was mutated to an identity and confirmed to FAIL. Turning `<` into `<=` is an equivalent mutant -- identical output for every input -- so it is not evidence of a gap either way. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
…ct (#128) * feat(delegate): report whether a subscribe actually pinned the contract `DelegateCtx::subscribe_contract`'s doc now describes at length that a subscribe may register no demand, and that a delegate cannot detect which it got. This adds the way to detect it. `true` means "the node accepted the registration", not "the contract is pinned". A registration accepted without durable demand is reported identically to one that pinned, so the delegate believes it holds durable interest and does not: the contract stays evictable and notifications later stop at a moment the delegate never observes. Adds `subscribe_contract_checked`, returning `SubscribeOutcome` (`Pinned` / `NotPinned` / `Unrecognized`). `is_pinned()` is true only for `Pinned`: an outcome this build cannot interpret is not evidence of a pin, and folding it into success would reintroduce the exact defect. This is deliberately NOT a wire-format change. It is a host function and a plain Rust type, so it takes no bincode variant tag and cannot shift one -- additive in both directions. A delegate that does not import it is unaffected; one that does fails to *instantiate* on too old a node with a named missing-import error, loudly and at load time. Same reasoning as `list_subscriptions`, which this complements rather than duplicates: that reads the subscription set, which contains accepted-but-unpinned registrations, so it answers "what did I register?" where this answers "what actually got pinned?". `subscribe_contract` is left behaviourally unchanged; altering its return would change the behaviour of already-deployed delegate WASM. Reapplied onto main after #98 merged rather than rebased: #98 was squash-merged, so replaying this branch would have re-applied #98's own commits against a main that already contains them. Reapplying also let this build on #98's much richer subscribe_contract doc, pointing at the new method instead of overwriting what that doc now explains. No version change -- main is already at 0.10.0 from #98. Host half: freenet/freenet-core#5565. No released node provides the import yet. Verified: 130 tests pass; the unknown-outcome guard was mutated (unknown folded into `Pinned`) and confirmed to FAIL. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV * fix(review): export SubscribeOutcome from the prelude, and return i64 Two findings from the wire review, both free to fix now. `SubscribeOutcome` was missing from `prelude`'s explicit re-export list, so `use freenet_stdlib::prelude::*` brought in `subscribe_contract_checked` but not the type its result must be matched on. Matching the outcome is the entire point of the API, and it needed a second, differently shaped import to do it. The error type is now `i64`, matching both the host function's own return type and `list_subscriptions` from #98. The previous `i32` narrowed with `unwrap_or(i32::MIN)`, so a code outside i32 range became -2147483648 -- a value no host ever sent and that appears in no error table. Every available narrowing answer invents a code; not narrowing does not. The prelude guard was rewritten after the first version proved vacuous. It used `use crate::prelude::*` inside a test module that already has `use super::*` in scope, so the name resolved from the parent module and the test passed with the re-export deleted -- confirmed by deleting it. It now names `crate::prelude::SubscribeOutcome` in full, and deleting the re-export fails the build with E0433. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV * fix(review): define Pinned against what core actually provides BLOCKER from the wire review. `Pinned` said the contract "enters the renewal set" and is "exempt from eviction". Neither has a referent, and this matters because CODE_PINNED = 0 is about to freeze as host ABI. Verified against freenet-core: `DELEGATE_SUBSCRIPTIONS` appears nowhere in `ring/hosting` -- it is inserted on subscribe, read for notification fan-out, cleaned up on unregister, and feeds no hosting decision. And eviction exemption is not on offer to anyone: `demand-driven-hosting.md` names three demand sources and never mentions delegates, says of the subscriber dimensions that "neither is an absolute pin", and `ring/hosting/cache.rs:899` says a subscribed contract is "NOT hard-pinned". Piece E is actively removing the durable-regardless-of- demand copy as an anti-pattern. So this was not an unimplemented feature; it promised the one thing the architecture is moving away from. The consequence was worse than a wrong doc: a correct host would return NotPinned unconditionally, making the API a constant, and a delegate following the advice would back off forever against a condition that never clears -- inverting the purpose, since it exists so a seller learns their 51st order is not live. `Pinned` now means what the PR's own implementer note already said: the node holds state for this contract and has recorded the delegate's interest. Observable today, needs no hosting-policy change, and is honest about its scope -- it states that no subscription of any kind is an absolute pin, so this is a claim about now rather than durability. `NotPinned` is correspondingly retryable and clears once the node holds the state, which is the ordinary startup case. Deliberately did NOT add CODE_CAP_EXCEEDED: `#[non_exhaustive]` plus `Unrecognized` means a later addition degrades safely, and once Pinned means "holds state" the cap distinction stops carrying its own weight. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV * fix(review): a delegate subscription does not affect eviction ordering M5. The `Pinned` doc said "no subscription of any kind is an absolute pin -- a subscribed contract is ordered last for eviction, not exempt from it". The first half is right. The trailing clause is true of CLIENT subscriptions and false of delegate ones: DELEGATE_SUBSCRIPTIONS feeds nothing in ring/hosting, so a delegate subscribe contributes no hosting demand and does not move the eviction order at all. In a delegate-facing doc the reader applies that sentence to their own subscription, so it read as a weak guarantee where there is none. Now says what is true: it registers notification interest only. Also adds the cross-PR sentence. This check happens at subscribe time and nowhere else, so a subscription that was live can quietly stop being so; re-checking without a UI needs freenet-stdlib#82's schedule_wakeup. Neither change alone closes freenet-core#5565's scenario -- this one lets a delegate learn the truth, that one lets it ask again -- and an adopter reading `Pinned` as fire-and-forget would get exactly the silent failure the PR exists to remove. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV * docs: scope the no-eviction-demand claim to pre-#4669 core Comment-only. `Pinned` said a delegate subscription "does not affect eviction ordering at all", unqualified, in the same file as a doc saying that changes once #4669 lands -- and freenet-core#5493 implements #4669 and is open right now, so this is not a distant hypothetical. True today and defensible, since `Pinned` is already scoped as a statement about now. But an unqualified "at all" is the kind of sentence a reader carries away without its scope, so it now names the scope inline. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
…ounds Round-2 findings. M1 is behavioural; the rest are the same defect in prose, which is that this crate was describing obligations on an unwritten host in the present tense. M1: `schedule_wakeup` now clamps `after` up to MIN_WAKEUP_DELAY itself. The doc said the host "will honour" the floor, that the constant "states the contract so a delegate author can rely on it", and that "#3972 enforces it" -- three lines above a paragraph correctly saying no host does anything yet. Two registers in one comment, and the second was the honest one. One line in the wrapper makes the sentence true of the API the delegate actually calls, and it is how MAX_WAKEUP_TAG_BYTES was already handled; enforcing one documented bound locally while delegating the other to an unwritten host had no stated reason. The clamp is a pure `clamp_wakeup_delay` rather than an inline `.max()`, because the call site is inside `cfg(target_family = "wasm")` and CI never executes that branch -- the wasm32 jobs build and lint only. A clamp living only there would be type-checked and unverified, which is the "twelve tests that never ran" shape #98 found in this same crate. M3: the test comment said the tag cap "must be enforced before the host call, not by the host". That is backwards, and it was addressed to the person who has to write the host check. `__frnt__delegate__schedule_wakeup` is an ordinary WASM import, so a delegate can declare its own extern block and pass a 10 MB tag without ever touching DelegateCtx. A guest-side check on a guest-controlled import is fail-fast convenience and can never be a bound. Only the host can enforce it. Generalised in the comment, since it is true of every bound this crate documents. M2: "the host bounds how many wakeups a delegate may hold pending" was present tense in two places and false. It is load-bearing rather than incidental -- the reader is told a count cap exists in order to explain why a size cap is also needed -- so it is now an obligation on #3972 alongside durability. M4/L1: deleted a stale CHANGELOG run. It claimed `Duration` was on the wire with a byte layout pinned by a test; neither survived the M1 conversion, which moved the delay to `after_millis: i64` across the FFI and removed the outbound pin entirely. It also carried a duplicate durability section and a `### Why a delay` heading that escaped the entry it belonged to. Replaced with a Compatibility section describing what actually ships. L2: removed the superseded no-context rationale from the `get_context` comment. Two rationales coexisted and the weaker one was what a maintainer hits first when editing the accessor. Verified: 133 tests pass; both clippy invocations clean; fmt clean. The clamp guard was mutated to an identity and confirmed to FAIL. Turning `<` into `<=` is an equivalent mutant -- identical output for every input -- so it is not evidence of a gap either way. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
…upFired) (#82) * feat: scheduled wakeup primitive for delegates (ScheduleWakeup / WakeupFired) Adds the two wire variants the host<->delegate protocol needs for a delegate to run periodic background work with no UI attached: OutboundDelegateMsg::ScheduleWakeup { after: Duration, tag: Vec<u8> } InboundDelegateMsg::WakeupFired { tag: Vec<u8> } Both appended at tag 9, behind the unsubscribe pair at tag 8. Driving use case is River's weekly secret rotation (freenet/river#228), which today has to live in a client sync loop that stops when the tab closes. `after` is a DELAY, not a deadline, and that is the load-bearing decision. An earlier draft used `at: SystemTime`; a delegate cannot fill that field. Delegates compile to wasm32-unknown-unknown, where `SystemTime::now()` compiles and then panics at runtime, and freenet-core registers no temporal host function in any of the four delegate namespaces -- so there is no value a delegate could compute for an absolute deadline, including the "one week from now" this primitive exists to express. A delay is also strictly more capable. If a clock host function is ever added, absolute scheduling is `target - now` in terms of this same field; an absolute field would gain nothing it did not already need that clock for. A delegate re-arming inside its handler just asks for the same delay again, so the recurring case needs neither a clock nor a timestamp on the fire -- which avoids adding a field the host could only fill meaningfully or leave permanently useless. It also removes two failure modes rather than an ambiguity: a pre-epoch `SystemTime` fails to serialize on the SENDER, and wall-clock steps are now the host scheduler's business rather than a semantic left undefined on the wire. `Duration` encodes identically (u64 secs LE + u32 nanos), so the wire size is unchanged. Durability is stated as a REQUIREMENT ON THE HOST and explicitly not implemented. A week-long delay is only useful if pending wakeups survive a restart, no host does that today, and nothing in this crate can make it true. The precedent runs the wrong way: DELEGATE_SUBSCRIPTIONS, the one comparable piece of per-delegate host state, is an in-memory LazyLock<DashMap> a restart discards. A wire doc asserting a durability guarantee nothing provides is worse than silence. WakeupFired carries no DelegateContext, and that exemption is named rather than wildcarded: a context is per-conversation state handed back on a reply, and a wakeup opens a conversation rather than continuing one. Carrying one would commit the host to persisting delegate context across arbitrary wall-clock time, which is #5467 Phase 3. The pin test asserts the WHOLE byte layout, not just the variant tag. Duration's encoding is a property of serde's impl, not of this crate, so a round-trip test would keep passing while every deployed delegate disagreed with the host about how long a week is. Verified: 127 tests pass; both clippy invocations clean; fmt clean. The byte pin was mutated twice and confirmed to FAIL -- once by swapping the field order, once by perturbing the fixture. Host half: freenet/freenet-core#3972. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV * feat(delegate): scheduled wakeup — host function out, WakeupFired in M1 from the wire review, and it changes the shape rather than the wording. `ScheduleWakeup` is no longer an OutboundDelegateMsg variant; it is `DelegateCtx::schedule_wakeup`, a host function in the `freenet_delegate_management` namespace. `WakeupFired` stays an inbound wire variant at tag 9. The reason is the batch boundary. A delegate's outbound messages are serialized as ONE batch (`delegate_interface.rs`, a single `bincode::serialize` of `Result<Vec<OutboundDelegateMsg>, _>`) and decoded whole by the host, so an outbound variant the host does not know fails the ENTIRE batch. A delegate built against this release returning `[ApplicationMessage(reply), ScheduleWakeup{..}]` to a current-release node would have the reply discarded along with the wakeup, and the user's action would silently do nothing. That is the direction ordinary rollout produces every time, since stdlib ships before core by policy. A host function fails the other way: an unimported function fails at instantiation with a named missing import -- loudly, once, at load. outbound variant would have put two opposite answers to one question in one release. WakeupFired stays a wire variant because that direction has no equivalent hazard: a delegate that cannot schedule never receives one. OUTBOUND_VARIANT_COUNT returns to 9; inbound stays at 10. Also from the review: - M4: `tag` is capped at MAX_WAKEUP_TAG_BYTES (128), enforced before the host call. Only the pending COUNT was bounded, which makes the tag an unbounded byte channel behind a bounded-looking limit -- the same defect as a cache capped by entry count while holding caller-chosen values. - M5: MIN_WAKEUP_DELAY (1s), documented as a host clamp. A delegate re-arming inside its own handler with a zero delay would otherwise spin the node in a tight wake loop; moving from a deadline to a delay did not remove that hazard, it only removed the "deadline in the past" spelling of it. - M6: documented what the context cache holds during a wakeup. It is keyed PER DELEGATE, not per conversation, and prunes after 10 minutes, so any wakeup worth scheduling outlives the context that existed when it was scheduled -- and a live context inside that window belongs to a different exchange. That is a second, independent reason WakeupFired carries no context: there is no coherent value to put there. Verified: 130 tests pass; both clippy invocations clean; wasm32 build clean; fmt clean. The two new bounds guards were mutated and confirmed to FAIL -- once by removing the tag cap, once by an off-by-one turning `>` into `>=`, which the exactly-at-the-cap test catches. Host half: freenet/freenet-core#3972, which must supply the delay measured from receipt, the MIN_WAKEUP_DELAY clamp, a bound on pending wakeups per delegate, and persistence across restart. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV * fix(review): enforce the delay floor, and stop calling guest checks bounds Round-2 findings. M1 is behavioural; the rest are the same defect in prose, which is that this crate was describing obligations on an unwritten host in the present tense. M1: `schedule_wakeup` now clamps `after` up to MIN_WAKEUP_DELAY itself. The doc said the host "will honour" the floor, that the constant "states the contract so a delegate author can rely on it", and that "#3972 enforces it" -- three lines above a paragraph correctly saying no host does anything yet. Two registers in one comment, and the second was the honest one. One line in the wrapper makes the sentence true of the API the delegate actually calls, and it is how MAX_WAKEUP_TAG_BYTES was already handled; enforcing one documented bound locally while delegating the other to an unwritten host had no stated reason. The clamp is a pure `clamp_wakeup_delay` rather than an inline `.max()`, because the call site is inside `cfg(target_family = "wasm")` and CI never executes that branch -- the wasm32 jobs build and lint only. A clamp living only there would be type-checked and unverified, which is the "twelve tests that never ran" shape #98 found in this same crate. M3: the test comment said the tag cap "must be enforced before the host call, not by the host". That is backwards, and it was addressed to the person who has to write the host check. `__frnt__delegate__schedule_wakeup` is an ordinary WASM import, so a delegate can declare its own extern block and pass a 10 MB tag without ever touching DelegateCtx. A guest-side check on a guest-controlled import is fail-fast convenience and can never be a bound. Only the host can enforce it. Generalised in the comment, since it is true of every bound this crate documents. M2: "the host bounds how many wakeups a delegate may hold pending" was present tense in two places and false. It is load-bearing rather than incidental -- the reader is told a count cap exists in order to explain why a size cap is also needed -- so it is now an obligation on #3972 alongside durability. M4/L1: deleted a stale CHANGELOG run. It claimed `Duration` was on the wire with a byte layout pinned by a test; neither survived the M1 conversion, which moved the delay to `after_millis: i64` across the FFI and removed the outbound pin entirely. It also carried a duplicate durability section and a `### Why a delay` heading that escaped the entry it belonged to. Replaced with a Compatibility section describing what actually ships. L2: removed the superseded no-context rationale from the `get_context` comment. Two rationales coexisted and the weaker one was what a maintainer hits first when editing the accessor. Verified: 133 tests pass; both clippy invocations clean; fmt clean. The clamp guard was mutated to an identity and confirmed to FAIL. Turning `<` into `<=` is an equivalent mutant -- identical output for every input -- so it is not evidence of a gap either way. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV * feat(delegate): scheduled wakeup — host function out, WakeupFired in `ScheduleWakeup` is a host function, `DelegateCtx::schedule_wakeup`, in the `freenet_delegate_management` namespace. `WakeupFired` is an inbound wire variant at tag 9. A delegate's outbound messages are serialized as ONE batch and decoded whole by the host, so an outbound variant the host does not know fails the ENTIRE batch: a delegate built against this release returning `[ApplicationMessage(reply), ScheduleWakeup{..}]` to a current-release node would have the reply discarded along with the wakeup. That is the direction ordinary rollout produces every time, since stdlib ships before core. A host function fails at instantiation instead, with a named missing import -- loudly, once, at load. ## Rebase onto main after #128 merged `rust/src/delegate_host.rs` conflicted; `CHANGELOG.md` merged cleanly. WHAT I KEPT: both sides, in full, at both conflict sites. #128 added the `SubscribeOutcome` material and this PR adds the wakeup material; they were inserted at the same two anchors (after the MAX_SUBSCRIPTION_LIST_BYTES assert, and at the end of the test modules) and are independent. Neither side was dropped or edited during the resolution -- the later commits on this branch apply their own review fixes on top, so editing the text at resolution time would have conflicted with them. CHECKED FOR THE SEAM: both PRs document what the host does and does not guarantee, which is where a present-tense claim could reappear. It did not. #128's side reads "a statement about *now*, not a durability promise" and "on freenet-core as it stands (pre-#4669)"; this side reads "expected to bound", "must clamp too, and does not yet", and "an obligation on freenet-core#3972, not a property of anything shipped". Grep for the old present-tense forms ("the host bounds", "host will honour", "#3972 enforces") returns nothing. The two blocks agree. ONE THING THE RESOLUTION BROKE, caught by the compiler: taking both sides verbatim at the test-module conflict dropped `subscribe_outcome_tests`'s closing brace and the following `#[cfg(test)]`, because the closing brace sat in shared trailing context rather than inside either side. Restored. Confirmed both modules actually execute rather than merely compiling: subscribe_outcome_tests 6 tests, schedule_wakeup_guard_tests 7, wakeup_delay_clamp_tests 3. Verified: 142 tests pass; both clippy invocations clean; wasm32 build clean; fmt clean. Host half: freenet/freenet-core#3972, which must supply the delay measured from receipt, the MIN_WAKEUP_DELAY clamp, a bound on pending wakeups per delegate, persistence across restart, and the opt-in property that keeps WakeupFired safe. Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
…dispatchers The choice of an explicit error over a silent drop for `OutboundDelegateMsg::UnsubscribeContractRequest` was the most load-bearing decision in this PR and nothing exercised it. Every other hunk touching the variant just widens a pre-existing "unexpected variant -> panic" arm, which is compile-driven churn, not coverage. Two tests, one per dispatcher, because they fail through different machinery and a regression in either alone would be invisible: - `unsubscribe_contract_request_fails_the_run_rather_than_being_dropped` drives `Runtime::process_outbound` directly via the existing `bare_runtime()` fixture and asserts `Err`, that the message names the request and #5600, and that `results` stayed empty so nothing was forwarded as though handled. Both `processed` states are covered. - `delegate_unsubscribe_request_surfaces_not_implemented_to_the_client` scripts the mock runtime to emit the request and asserts the CLIENT sees `DelegateResponse(Err(_))` naming the same. The mock does not go through `process_outbound`, so this is the only cover for the executor loop's arm. Both assert the error TEXT, not merely `is_err()`: a delegate request against that handler can fail for unrelated reasons, so an `is_err()` check would pass just as happily with the arm deleted. Both were verified by making them FAIL. Replacing each arm with a silent drop turns the loop test's result into `Ok([])` -- an empty SUCCESS, which is exactly the failure being guarded against: it reads as an unsubscribe that happened while the subscription is still live, and leaves the delegate waiting forever for a response nobody will send. Refs #5600, freenet/freenet-stdlib#98 Claude-Session: https://claude.ai/code/session_01RbvrRHmbnmNWywtSgG8qEV
Problem
Three things, all in the delegate API surface, all of the same shape: something that looks like it is guaranteed and isn't.
1. The wire-format pin covered one variant out of sixteen.
inbound_delegate_msg_wire_format_is_stableasserted thatInboundDelegateMsg's variant 0 isApplicationMessage. Nothing coveredOutboundDelegateMsgat all, and nothing covered any variant after the first. So any reorder that happened to leaveApplicationMessagein front was undetected — swappingUserResponseandGetContractResponse, for instance, reassigns two bincode tags and makes already-deployed delegate WASM decode each as the other. Silently: the bytes still parse, into the wrong variant.That is not a hypothetical. Exactly that swap was written and staged during the work that led to this PR, in a change whose stated purpose was to protect the wire format.
2.
InboundDelegateMsg's doc comment asserted a fact about the code that was false. It saidOutboundDelegateMsgwas already#[non_exhaustive]. It never has been. Anyone reasoning about whether a variant addition was source-breaking got the wrong answer from the documentation.3. A delegate cannot ask what it is subscribed to. A delegate's subscription set lives in the node, not in the delegate: the WASM is instantiated per invocation and dropped immediately after, and the node replays subscriptions across a restart without running the delegate at all. So after a restart a delegate has no way to learn its own state. It can keep a parallel record in its secrets — which drifts from the node's exactly in the cases that matter — or re-subscribe to everything on every wake. freenet/freenet-core#5467 names this as blocking restart-replay.
Approach
Pin every tag, and fail closed in both directions.
delegate_msg_variant_tags_are_pinnedasserts the bincode tag of every variant of both enums. Two mechanisms keep it honest, because a pin that can rot is not a pin:match.#[non_exhaustive]has no effect inside the defining crate, so adding a variant without pinning it is a compile error.PutContractRequestis covered too. The obvious shortcut is to skip it because building aContractContaineris awkward; it is four lines, and a pin with a hole in it reads as coverage while providing none.Assert the compatibility rules instead of stating them.
delegate_wire_compatandstruct_field_wire_compatestablish what bincode actually does, because appending an enum variant and appending a struct field break in opposite directions and it is easy to carry the wrong intuition from one to the other:A struct field is the more dangerous of the two: bincode is positional and carries no field tags, so there is nothing for a decoder to skip.
#[serde(default)]does not make a field optional on this path — it is a self-describing-format feature and protectsserde_jsononly, which is easy to misread givenContractState::size_bytescarries it. The practical rule that follows is to prefer a new enum variant over a new field on an existing wire struct, since a variant is only ever seen by a peer that asked for it.NodeDiagnosticsResponseis terminal in its message, which is the only reason a field can be appended to it without corrupting what follows. That property is invisible at the definition site, so it is now pinned rather than assumed.Introspection as a host function, not a message variant.
DelegateCtx::list_subscriptionsis backed by two new V2 host functions in the existingfreenet_delegate_contractsnamespace. Host functions resolve by name at module instantiation, so this is additive for every existing delegate — one that does not import it is unaffected, and one that does fails to load on a node too old to provide it, with a named missing-import error. Compare a new enum variant, which fails mid-protocol at bincode decode with no way for the delegate to have checked first. Where a capability can be expressed either way, the host function has the better failure mode.It returns
Result<Vec<[u8; 32]>, i64>, not a bareVec. An empty list and a failed enumeration mean opposite things to a caller deciding whether to re-subscribe, and collapsing them is how a delegate concludes its user's content is unpinned because a host call failed. For the same reason the off-WASM stub returnsErr, so a host-side test cannot read "no subscriptions" out of a stub that never had any.OutboundDelegateMsgstays not#[non_exhaustive], deliberately. The false doc comment is fixed by correcting it, not by making it true. freenet-core dispatches this enum in exhaustive matches with no wildcard (contract.rs, in the request loop and again in the app-message filter). Marking it would force those to grow_ =>arms, and a newly added variant would then compile against the host with no handler — the delegate's request silently swallowed while the call reports success. That is the failure mode a delegateSubscribeContractRequesthas today (freenet/freenet-core#4669), and the compile error is what stops the next one.InboundDelegateMsgkeeps the attribute because its consumers are third-party delegate WASM, which can reasonably ignore an unknown variant. The asymmetry is now documented on both enums, and matches the position already taken in #82.Also corrects
subscribe_contract's doc comment, which said notification delivery was "a follow-up" (it works) while saying nothing about the fact that a delegate subscription registers no demand in the network — it does not pin the contract, enter the renewal set, or exempt it from eviction, so a delegate sees remote updates only while some other route keeps the node subscribed. The call succeeds either way and nothing distinguishes the two, which is why it belongs at the call site rather than in an issue.Compatibility
Wire change: two variants appended, nothing moved.
UnsubscribeContractRequestjoinsOutboundDelegateMsgandUnsubscribeContractResponsejoinsInboundDelegateMsg, both at tag 8. Every pre-existing tag is exactly where it was, so deployed delegate WASM built against an older stdlib is unaffected — the append-safe direction of the table above. A hand-built pre-0.9.0ContractNotificationpayload is asserted to still decode unchanged.Tag 8 is a deliberate allocation, coordinated with #82: that PR appends
ScheduleWakeup/WakeupFiredand moves to tag 9. Recorded as a comment on #82 so it does not live only in a working session.The pin was made to fail before it was made to pass. Adding the two variants with nothing else changed produced five
E0004non-exhaustive-pattern errors — three in production code, including the FlatBuffers encoder that theOutboundDelegateMsgdoc cites as the reason that enum is deliberately not#[non_exhaustive], and two in the tag map itself:So the guard is demonstrated rather than asserted: a variant cannot be appended without both the host-dispatch site and the wire pin refusing to compile.
The two new host imports require a node whose freenet-core registers them. That is a load-time failure by design, not a silent one. The host half is not in this PR — for host functions the usual stdlib-first order is inverted, since core's linker registration references no stdlib type and can land first. Coordinated with the agent owning
crates/core/src/contract.rs.Testing
New:
delegate_msg_variant_tags_are_pinned,every_variant_is_covered_by_the_pin,an_unpinned_variant_fails_this_test,an_old_payload_still_decodes_after_appending_a_variant,a_new_variant_does_not_decode_on_an_old_receiver, the fivestruct_field_wire_compatcases includingnode_diagnostics_response_is_terminal_in_its_message, and three round-trip cases for the contract-id codec.The old-payload tests use hand-built byte strings rather than values produced by this crate's own encoder. An encoder-produced payload would only prove the code agrees with itself, which is not the property under test.
[AI-assisted - Claude]
Also in this PR: two fixes in
memory/buf.rsFlagged here because it is a file otherwise unrelated to this change, so a reviewer should not meet it by surprise.
StreamingBuffer::from_ptris apub unsafe fnthat had no documentation, because its entire doc comment — including the# Safetycontract — was attached tototal_remaining, a safe getter two lines above that takes no pointer. So the getter was documented as "Create a streaming reader from a buffer pointer" with a safety contract about aptrit does not have, while the constructor whose callers must uphold that invariant said nothing at all. A method had been inserted into the middle of another method's doc block. Splitting them back apart also clearsclippy::missing_safety_doc.The second is a false positive: the non-WASM stub must keep the mangled
__frnt__fill_buffername to match the WASM import it stands in for, so it takes a narrow#[allow(non_snake_case)]with the reason recorded.Neither is reachable by the lint gate.
ci.yml:99passes no--features. The clippy matrix does cover bothwasm32-unknown-unknownandx86_64-unknown-linux-gnu, so target coverage is fine — butdefault = [], sofeature = "contract"is off in both legs, and both of these live behind#[cfg(feature = "contract")]. The contract-side API has therefore never been linted. Filed as #100 rather than fixed here, because CI is a shared Full-tier surface and widening the lint surfaces a backlog of unknown size.Verification
Run locally, both the gate CI actually applies and a stricter one, because those are different questions — the first is what merges, the second is what stops the next person inheriting warnings:
cargo clippy --target wasm32-unknown-unknown -- -D warnings— clean (CI's exact invocation)cargo clippy --all-targets --features contract,net,testing,trace -- -D warnings— cleancargo test --features contract,net,testing,trace— 111 passed, 0 failed; doc-tests 1 passedcargo build --target wasm32-unknown-unknown --features contract— succeeds, which is the first time anything has compiled thecfg(target_family = "wasm")branch oflist_subscriptionsNote for whoever rebases #82
It appends
ScheduleWakeup/WakeupFiredto these same two enums, at the tag this PR leaves free. Two things to know:mainsince July, including thefixed_size_fielddecode-panic hardening and theunknown_union_discriminantchange. That is branch staleness rather than intent, but it needs handling in the rebase regardless of which order the two land in.The pins were verified to run, and to fail
A filtered
cargo testthat matches nothing prints0 passedand exits 0. That is indistinguishable from a clean run by exit code, and a reviewer cannot tell the two apart by reading the output either. So every check below asserts a test count, neverrc:Counting is necessary and not sufficient — a test that runs can still be one that cannot fail. That is exactly what review found in two of these guards, so both were mutated to confirm they now fail when the thing they guard is broken:
INBOUND_VARIANT_COUNTleft stale at 8 after appending a 9th variantRestored tree: 125 passed, 0 failed.
The first mutation is the drift scenario the count constants exist for; the second is the precise regression three reviewers found independently. Neither guard could previously fail for its stated reason.
A note on the external review
codex reviewconverged on the unknown-tag probe defect independently — the strongest signal in the review, since three Claude lenses and a non-Claude model found the same hole from different directions.Its proposed remedy would never have worked. It recommended asserting
ErrorKind::InvalidTagEncoding. bincode never produces that for an enum tag: there is exactly one construction site, indeserialize_option(bincode-1.3.3/src/de/mod.rs:340). An out-of-range variant index goes throughidx.into_deserializer()into serde's derived visitor and comes back asErrorKind::Custom("invalid value: integer ..., expected variant index 0 <= i < N").The trap is that bincode's description string for that variant reads "tag for enum is not valid" — so the external model was misled by the description exactly as this PR's original code was misled by the name. A test written to its suggestion would have failed permanently while looking correct.
Worth stating as a general point about external review: treat its diagnosis as signal and its remedy as a hypothesis. The diagnosis here was right and valuable; the fix was wrong in a way that would have been easy to apply without checking.
The host half: owner, and why it cannot be linked yet
The unsubscribe variants are useless without a freenet-core handler, and shipping a variant no host handles is the exact defect this workstream exists to remove. So, plainly:
crates/core/src/contract.rs, by the file-ownership rule —contract.rs:915'sTODO(#2830)and the V1 outbound dispatch atcontract.rs:660-679are both in that file. Assignment confirmed by the team lead and accepted.UnsubscribeContractRequestuntil 0.9.0 is published, so the handler is necessarily downstream of this merge and release. Requiring the number as a merge condition would be a deadlock.The handler will route both teardowns — the ring demand registration and the
DELEGATE_SUBSCRIPTIONSnotification hook — through a single helper rather than removing them in two places, so the two records cannot drift apart. That is the same desync class as freenet-core#5487, arriving from the opposite direction.A correction worth recording, because it is the kind of reassurance that quietly replaces a check. An earlier version of this PR said the exhaustive match in freenet-core means a new variant "cannot compile against a host with no handler". That is too strong. It cannot compile without an arm; an arm returning
Ok(())compiles perfectly and is precisely the silent stub in question. This crate proves it — the FlatBuffers encoder has five arms that log the message and drop it. The compile error is a backstop against forgetting, and no backstop at all against stubbing.Two findings from review that are the same lesson from opposite directions
Recorded because both are cheap to repeat and neither is visible in a passing test run.
A cosmetic edit inverted a guard. Replacing
expect_errwithunwrap_or_elseto make a panic message interpolate turned the assertion into its opposite —unwrap_or_elseunwrapsOkand runs the closure onErr, so the test would have demanded the probe succeed. The compiler caught it, but nothing about the change looked like a behavioural one.Twelve tests existed and had never executed. The test count went 113 → 125 not because tests were added, but because the
list_subscriptionsguards were extracted out of#[cfg(target_family = "wasm")]. CI runscargo teston the host only; the wasm32 matrix entries build and lint and execute nothing. The guards were type-checked and unrun — which reads as coverage in the config and provides none. See #100 for the related lint-gate blindness and #101 for four sibling sites with the same unvalidated-length shape.